D:\a\csshw\csshw\src\daemon\grid.rs
Line | Count | Source |
1 | | //! Spatial grid model of the client windows. |
2 | | //! |
3 | | //! The tiler in [`super`] arranges `n` client windows on a grid of |
4 | | //! `cols * rows` cells, with the final row possibly stretched to span |
5 | | //! the full width when `n % cols != 0`. This module mirrors that layout |
6 | | //! as a pure data structure so the enable/disable submenu can navigate |
7 | | //! the cells spatially with arrow keys and `hjkl`. |
8 | | |
9 | | #![deny(clippy::implicit_return)] |
10 | | #![allow(clippy::needless_return, clippy::doc_overindented_list_items)] |
11 | | |
12 | | use std::cmp::max; |
13 | | use std::collections::HashMap; |
14 | | |
15 | | use super::NavigationDirection; |
16 | | use crate::utils::config::EdgeBehavior; |
17 | | |
18 | | /// One client window's position on the spatial grid. |
19 | | /// |
20 | | /// `col` is the leftmost upper-grid column the cell projects onto and |
21 | | /// `col_span` is how many upper-grid columns it spans. Rows `0..rows-2` |
22 | | /// are dense (`col_span == 1`); cells in a partial last row are stretched |
23 | | /// (`col_span >= 1`). |
24 | | #[derive(Debug, PartialEq, Eq, Clone, Copy)] |
25 | | pub(super) struct GridCell { |
26 | | /// Process id of the client owning this cell. |
27 | | pub pid: u32, |
28 | | /// 0-based row index. |
29 | | pub row: i32, |
30 | | /// Leftmost upper-grid column the cell projects onto. |
31 | | pub col: i32, |
32 | | /// Number of upper-grid columns the cell projects onto. Always |
33 | | /// `>= 1`; only `> 1` for partial-last-row cells. |
34 | | pub col_span: i32, |
35 | | /// 0-based position within the row, used for vertical roundtrips. |
36 | | pub pos_in_row: i32, |
37 | | } |
38 | | |
39 | | /// Spatial-grid view over the tracked client PIDs. |
40 | | pub(super) struct ClientGrid { |
41 | | /// Number of columns in the dense upper rows. |
42 | | pub cols: i32, |
43 | | /// Total number of rows (`>= 1` whenever there is at least one cell). |
44 | | pub rows: i32, |
45 | | /// Cell count in the last row. `0` means the last row is also dense; |
46 | | /// otherwise `1..cols` last-row cells are stretched proportionally. |
47 | | last_row_count: i32, |
48 | | /// Cells sorted by `(row, col)` so the top-left cell is at index `0`. |
49 | | cells: Vec<GridCell>, |
50 | | /// PID lookup table. |
51 | | by_pid: HashMap<u32, usize>, |
52 | | } |
53 | | |
54 | | /// Compute the grid dimensions for `n` clients on a workspace with the |
55 | | /// given aspect ratio. |
56 | | /// |
57 | | /// Must match the formula used by the tiler in |
58 | | /// [`super::determine_client_spatial_attributes`] so layout and |
59 | | /// navigation stay in sync. |
60 | | /// |
61 | | /// # Arguments |
62 | | /// |
63 | | /// * `n` - Number of client windows. |
64 | | /// * `aspect` - `workspace_width / workspace_height` (including |
65 | | /// frame padding) of the available area. |
66 | | /// * `aspect_adj` - The `aspect_ratio_adjustment` daemon config. |
67 | | /// |
68 | | /// # Returns |
69 | | /// |
70 | | /// `(cols, rows)`, each clamped to a minimum of `1`. |
71 | 6 | pub(super) fn grid_dimensions(n: i32, aspect: f64, aspect_adj: f64) -> (i32, i32) { |
72 | 6 | let cols = max(((n as f64).sqrt() * (aspect + aspect_adj)) as i32, 1); |
73 | 6 | let rows = max((n as f64 / cols as f64).ceil() as i32, 1); |
74 | 6 | return (cols, rows); |
75 | 6 | } |
76 | | |
77 | | impl ClientGrid { |
78 | | /// Build the grid from `(pid, tile_index)` pairs and the dimensions |
79 | | /// returned by [`grid_dimensions`] for the same `layout_n`. |
80 | | /// |
81 | | /// `tile_index` is the position each client was assigned the last |
82 | | /// time the tiler positioned its window. Surviving clients keep |
83 | | /// their `tile_index` across closures, so passing them here together |
84 | | /// with the layout's original `layout_n` produces a grid whose cells |
85 | | /// land at the same `(row, col)` the user sees on screen - with |
86 | | /// gaps where a window was closed but no retile has happened yet. |
87 | | /// |
88 | | /// # Arguments |
89 | | /// |
90 | | /// * `cells` - `(pid, tile_index)` pairs for every surviving |
91 | | /// client. |
92 | | /// * `layout_n` - The `number_of_consoles` the on-screen layout was |
93 | | /// last computed with. Used to derive the |
94 | | /// partial-last-row stretch. |
95 | | /// * `cols` - Columns from [`grid_dimensions`] for `layout_n`. |
96 | | /// * `rows` - Rows from [`grid_dimensions`] for `layout_n`. |
97 | | /// |
98 | | /// # Returns |
99 | | /// |
100 | | /// A populated [`ClientGrid`]. |
101 | 12 | pub(super) fn from_tiled_pids( |
102 | 12 | cells: &[(u32, usize)], |
103 | 12 | layout_n: i32, |
104 | 12 | cols: i32, |
105 | 12 | rows: i32, |
106 | 12 | ) -> Self { |
107 | 12 | let last_row_count = if cols > 0 { layout_n % cols } else { 00 }; |
108 | 12 | let mut grid_cells = Vec::with_capacity(cells.len()); |
109 | 51 | for &(pid, tile_index) in cells12 { |
110 | 51 | let idx = tile_index as i32; |
111 | 51 | let row = if cols > 0 { idx / cols } else { 00 }; |
112 | 51 | let pos_in_row = if cols > 0 { idx % cols } else { 00 }; |
113 | 51 | let (col, col_span) = if row == rows - 1 && last_row_count != 025 { |
114 | 3 | let left = (pos_in_row * cols) / last_row_count; |
115 | 3 | let right = ((pos_in_row + 1) * cols - 1) / last_row_count; |
116 | 3 | (left, right - left + 1) |
117 | | } else { |
118 | 48 | (pos_in_row, 1) |
119 | | }; |
120 | 51 | grid_cells.push(GridCell { |
121 | 51 | pid, |
122 | 51 | row, |
123 | 51 | col, |
124 | 51 | col_span, |
125 | 51 | pos_in_row, |
126 | 51 | }); |
127 | | } |
128 | 80 | grid_cells12 .sort_by_key12 (|c| return (c.row, c.col)); |
129 | 12 | let by_pid = grid_cells |
130 | 12 | .iter() |
131 | 12 | .enumerate() |
132 | 51 | .map12 (|(i, c)| return (c.pid, i)) |
133 | 12 | .collect(); |
134 | 12 | return ClientGrid { |
135 | 12 | cols, |
136 | 12 | rows, |
137 | 12 | last_row_count, |
138 | 12 | cells: grid_cells, |
139 | 12 | by_pid, |
140 | 12 | }; |
141 | 12 | } |
142 | | |
143 | | /// Look up the cell owned by `pid`. |
144 | | /// |
145 | | /// # Arguments |
146 | | /// |
147 | | /// * `pid` - Process id to look up. |
148 | | /// |
149 | | /// # Returns |
150 | | /// |
151 | | /// `Some(&GridCell)` when present, `None` otherwise. |
152 | 55 | pub(super) fn cell(&self, pid: u32) -> Option<&GridCell> { |
153 | 55 | return self.by_pid.get(&pid).map(|&i| return &self.cells[i]53 ); |
154 | 55 | } |
155 | | |
156 | | /// PID of the top-left cell, or `None` for an empty grid. Used to |
157 | | /// re-anchor the submenu selection onto a sensible visual default. |
158 | 2 | pub(super) fn top_left_pid(&self) -> Option<u32> { |
159 | 2 | return self.cells.first().map(|c| return c.pid); |
160 | 2 | } |
161 | | |
162 | | /// `true` when the grid has no cells. |
163 | 33 | pub(super) fn is_empty(&self) -> bool { |
164 | 33 | return self.cells.is_empty(); |
165 | 33 | } |
166 | | |
167 | | /// Compute the anchor column for a cell. Horizontal moves overwrite |
168 | | /// the in-flight anchor with the destination cell's anchor. |
169 | | /// |
170 | | /// Upper-row cells: their `col` (each cell occupies exactly one |
171 | | /// upper-grid column). Partial-last-row cells: the upper-grid column |
172 | | /// containing the cell's x-midpoint. The latter makes a Down + Up |
173 | | /// roundtrip return to the original cell from any starting point. |
174 | | /// |
175 | | /// # Arguments |
176 | | /// |
177 | | /// * `cell` - The destination cell. |
178 | | /// |
179 | | /// # Returns |
180 | | /// |
181 | | /// The anchor column for the cell. |
182 | 5 | pub(super) fn anchor_for(&self, cell: &GridCell) -> i32 { |
183 | 5 | if cell.row == self.rows - 1 && self.last_row_count != 01 { |
184 | 0 | return ((2 * cell.pos_in_row + 1) * self.cols) / (2 * self.last_row_count); |
185 | 5 | } |
186 | 5 | return cell.col; |
187 | 5 | } |
188 | | |
189 | | /// Compute the next selection after one navigation keystroke. |
190 | | /// |
191 | | /// # Arguments |
192 | | /// |
193 | | /// * `pid` - Currently highlighted PID. |
194 | | /// * `anchor_col` - Anchor column carried from earlier moves. |
195 | | /// * `direction` - Direction of the keystroke. |
196 | | /// * `edge` - Behavior when the move would leave the grid. |
197 | | /// |
198 | | /// # Returns |
199 | | /// |
200 | | /// `Some((new_pid, new_anchor_col))` on a successful step. |
201 | | /// `None` when `pid` is not present in this grid (caller should |
202 | | /// re-anchor). |
203 | 23 | pub(super) fn step( |
204 | 23 | &self, |
205 | 23 | pid: u32, |
206 | 23 | anchor_col: i32, |
207 | 23 | direction: NavigationDirection, |
208 | 23 | edge: EdgeBehavior, |
209 | 23 | ) -> Option<(u32, i32)> { |
210 | 23 | let current = self.cell(pid)?0 ; |
211 | 23 | return match direction { |
212 | | NavigationDirection::Left | NavigationDirection::Right => { |
213 | 8 | self.step_horizontal(current, anchor_col, direction, edge) |
214 | | } |
215 | | NavigationDirection::Up | NavigationDirection::Down => { |
216 | 15 | Some(self.step_vertical(current, anchor_col, direction, edge)) |
217 | | } |
218 | | }; |
219 | 23 | } |
220 | | |
221 | | /// Horizontal step within `current.row`. Returns `None` only when |
222 | | /// the row somehow contains no cells (cannot happen for a valid |
223 | | /// `current` looked up from the grid). A clamped no-op preserves |
224 | | /// the in-flight `anchor_col` so a subsequent vertical step still |
225 | | /// targets the column the user originally carried over. |
226 | 8 | fn step_horizontal( |
227 | 8 | &self, |
228 | 8 | current: &GridCell, |
229 | 8 | anchor_col: i32, |
230 | 8 | direction: NavigationDirection, |
231 | 8 | edge: EdgeBehavior, |
232 | 8 | ) -> Option<(u32, i32)> { |
233 | 8 | let mut row_cells: Vec<&GridCell> = self |
234 | 8 | .cells |
235 | 8 | .iter() |
236 | 46 | .filter8 (|c| return c.row == current.row) |
237 | 8 | .collect(); |
238 | 28 | row_cells8 .sort_by_key8 (|c| return c.col); |
239 | 15 | let pos8 = row_cells.iter()8 .position8 (|c| return c.pid == current.pid)?0 ; |
240 | 8 | let next3 = match direction { |
241 | | NavigationDirection::Left => { |
242 | 2 | if pos == 0 { |
243 | 2 | match edge { |
244 | 2 | EdgeBehavior::Clamp => return Some((current.pid, anchor_col)), |
245 | 0 | EdgeBehavior::Wrap => *row_cells.last()?, |
246 | | } |
247 | | } else { |
248 | 0 | row_cells[pos - 1] |
249 | | } |
250 | | } |
251 | | NavigationDirection::Right => { |
252 | 6 | if pos + 1 >= row_cells.len() { |
253 | 4 | match edge { |
254 | 3 | EdgeBehavior::Clamp => return Some((current.pid, anchor_col)), |
255 | 1 | EdgeBehavior::Wrap => *row_cells.first()?0 , |
256 | | } |
257 | | } else { |
258 | 2 | row_cells[pos + 1] |
259 | | } |
260 | | } |
261 | 0 | _ => return None, |
262 | | }; |
263 | 3 | return Some((next.pid, self.anchor_for(next))); |
264 | 8 | } |
265 | | |
266 | | /// Vertical step into the target row, preserving the in-flight |
267 | | /// `anchor_col`. |
268 | 15 | fn step_vertical( |
269 | 15 | &self, |
270 | 15 | current: &GridCell, |
271 | 15 | anchor_col: i32, |
272 | 15 | direction: NavigationDirection, |
273 | 15 | edge: EdgeBehavior, |
274 | 15 | ) -> (u32, i32) { |
275 | 15 | let target_row13 = match direction { |
276 | | NavigationDirection::Up => { |
277 | 6 | if current.row == 0 { |
278 | 2 | match edge { |
279 | 1 | EdgeBehavior::Clamp => return (current.pid, anchor_col), |
280 | 1 | EdgeBehavior::Wrap => self.rows - 1, |
281 | | } |
282 | | } else { |
283 | 4 | current.row - 1 |
284 | | } |
285 | | } |
286 | | NavigationDirection::Down => { |
287 | 9 | if current.row + 1 >= self.rows { |
288 | 1 | match edge { |
289 | 1 | EdgeBehavior::Clamp => return (current.pid, anchor_col), |
290 | 0 | EdgeBehavior::Wrap => 0, |
291 | | } |
292 | | } else { |
293 | 8 | current.row + 1 |
294 | | } |
295 | | } |
296 | 0 | _ => return (current.pid, anchor_col), |
297 | | }; |
298 | 13 | let row_cells: Vec<&GridCell> = self |
299 | 13 | .cells |
300 | 13 | .iter() |
301 | 79 | .filter13 (|c| return c.row == target_row) |
302 | 13 | .collect(); |
303 | 13 | if row_cells.is_empty() { |
304 | 0 | return (current.pid, anchor_col); |
305 | 13 | } |
306 | 13 | let is_partial_last_row = target_row == self.rows - 1 && self.last_row_count != 07 ; |
307 | 13 | let best = row_cells |
308 | 13 | .into_iter() |
309 | 38 | .min_by_key13 (|c| { |
310 | 38 | return ( |
311 | 38 | self.anchor_distance(c, anchor_col, is_partial_last_row), |
312 | 38 | c.col, |
313 | 38 | ); |
314 | 38 | }) |
315 | 13 | .expect("row_cells just checked non-empty"); |
316 | 13 | return (best.pid, anchor_col); |
317 | 15 | } |
318 | | |
319 | | /// Spatial distance between a cell and an anchor column, used to |
320 | | /// pick the target on a vertical step. Dense rows reduce to |
321 | | /// `|c.col - anchor|`; partial-last-row cells use their stretched |
322 | | /// x-extent so the cell whose midpoint is closest to the anchor's |
323 | | /// centerline wins. The result is in arbitrary integer units valid |
324 | | /// only for comparisons within the same row. |
325 | 38 | fn anchor_distance(&self, cell: &GridCell, anchor_col: i32, is_partial_last_row: bool) -> i64 { |
326 | 38 | if is_partial_last_row { |
327 | 12 | let cell_mid = (2 * cell.pos_in_row as i64 + 1) * self.cols as i64; |
328 | 12 | let anchor_mid = (2 * anchor_col as i64 + 1) * self.last_row_count as i64; |
329 | 12 | return (cell_mid - anchor_mid).abs(); |
330 | 26 | } |
331 | 26 | return (2 * cell.col as i64 - 2 * anchor_col as i64).abs(); |
332 | 38 | } |
333 | | } |